Skip to content

嵌入通信协议

本文档面向需要在自身应用中嵌入 MaaPipelineEditor(下称 MPE)的开发者。MPE 通过 iframe 嵌入模式,使宿主应用(如 VSCode 插件、Web IDE 等)可以在不依赖 LocalBridge 后端服务的情况下,将流程编辑器集成到自有的界面中。

概述

在嵌入模式下,MPE 以 iframe 为容器运行,宿主与 MPE 之间通过 window.postMessage 进行双向通信。嵌入模式不连接任何后端服务,所有文件操作由宿主代理完成。

维度嵌入模式在线/独立模式
通信方式postMessageWebSocket
后端依赖LocalBridge
文件访问宿主代理LB 文件服务
设备连接不提供通过 LB
调试功能不提供通过 LB
UI 定制宿主可配置不可配置

激活方式

在 iframe 的 src 中添加 URL 参数即可激活嵌入模式:

https://mpe.codax.site/stable/?embed=true&origin=vscode-maa
参数必填说明
embed设为 true 激活嵌入模式
origin声明宿主来源,用于调试与可选的 origin 校验

origin 参数说明

origin 的值可以是两种形式:

  • 标识符(如 vscode-maatest-host):仅用于日志记录和调试,不做严格的 postMessage origin 校验
  • 完整 URL(如 https://my-app.com):MPE 会对收到的 postMessage 消息做严格的 event.origin 匹配,不匹配的消息会被丢弃

如果 originhttp 开头,MPE 会启用严格的 origin 校验;否则仅作为标识符使用。

消息格式

所有 postMessage 消息使用统一的信封格式:

typescript
interface EmbedMessage {
  protocol: "mpe-embed"; // 协议标识,防止消息串扰
  version: string; // 协议版本,如 "1.0.0"
  type: string; // 消息类型
  requestId?: string; // 请求 ID,用于请求-响应模式匹配
  payload: any; // 消息体
}

MPE 仅处理 protocol"mpe-embed" 的消息,其余 postMessage 消息会被静默忽略。

握手流程

iframe 加载完成后,宿主需主动发起握手:

宿主                              MPE (iframe)
  │                                  │
  │  ─── mpe:init ──────────────►    │
  │      { capabilities, ui }        │
  │                                  │
  │  ◄──── mpe:ready ─────────────   │
  │      { version, supportedCaps }  │
  │                                  │
  │    (握手完成,正式通信)           │
  1. iframe 加载完成后,宿主发送 mpe:init,携带权限声明UI 配置
  2. MPE 根据声明配置自身,回复 mpe:ready
  3. 握手完成前,MPE 忽略所有非 mpe:init 消息
  4. 5 秒内未收到 mpe:init,MPE 会使用默认权限集自动完成握手并继续运行

注意

mpe:init 消息中的 requestId 必须原样回填到 mpe:ready 中,否则宿主的请求会超时。建议宿主侧设置 10 秒超时。

宿主 → MPE 消息

类型说明payload响应
mpe:init握手 + 权限/UI 声明EmbedInitConfigmpe:ready
mpe:loadPipeline加载 pipeline JSON 数据{ fileName?, data }mpe:loadResult
mpe:save请求 MPE 回传当前流程数据{}mpe:saveData
mpe:selectNode选中指定节点{ nodeId }
mpe:focusNode聚焦并视口定位到指定节点{ nodeId }
mpe:state查询 MPE 当前状态{ fields: string[] }mpe:stateResult

加载流程数据

mpe:loadPipelinedata 字段为标准 MaaFramework pipeline JSON 对象。MPE 收到后会调用内部解析器将其转换为流程图渲染。

typescript
{
  type: "mpe:loadPipeline",
  requestId: "uuid",
  payload: {
    fileName: "pipeline.json",  // 可选,用于 saveData 回填
    data: { /* Pipeline JSON */ }
  }
}

MPE 响应 mpe:loadResult

typescript
{
  type: "mpe:loadResult",
  requestId: "uuid",  // 与请求相同的 requestId
  payload: {
    success: true,
    fileName: "pipeline.json"
  }
}

保存流程数据

宿主发送 mpe:save 后,MPE 将当前流程图序列化为 pipeline JSON 并回传:

typescript
{
  type: "mpe:saveData",
  requestId: "uuid",
  payload: {
    fileName: "pipeline.json",
    data: { /* Pipeline JSON */ }
  }
}

节点选中与聚焦

mpe:selectNodempe:focusNode 都通过 nodeId 定位节点。MPE 的实现支持两种查找方式:

  1. 按节点内部 ID(ReactFlow 的 node.id)精确匹配
  2. 按节点标签回退node.data.label,即节点显示名称)——当按 ID 找不到时自动尝试按标签匹配

如果两种方式都找不到节点,MPE 会发送 mpe:error

typescript
{
  type: "mpe:error",
  payload: {
    code: "node_not_found",
    message: "Node not found: 节点名称"
  }
}

mpe:focusNode 定位成功后,MPE 会调用 fitView 将视口平滑移动到目标节点(动画时长 300ms)。

状态查询

mpe:state 通过 fields 数组查询 MPE 的当前状态。目前支持的字段:

字段类型说明
versionstring嵌入协议版本,如 "1.0.0"
nodesCountnumber当前节点数量
edgesCountnumber当前边数量
fileNamestring | null当前文件名
readOnlyboolean是否处于只读模式

MPE → 宿主消息

类型说明payload
mpe:ready初始化完成,握手响应{ version, supportedCaps }
mpe:loadResult加载结果{ success, fileName?, error? }
mpe:saveData回传流程数据{ fileName, data }
mpe:change流程图变更通知{ type, detail }
mpe:nodeSelect用户选中节点{ nodeId, nodeData? }
mpe:saveRequestMPE 主动请求保存(如 Ctrl+S){ hint }
mpe:stateResult状态查询结果{ [field]: value }
mpe:error错误通知{ code, message, detail? }

变更通知

MPE 在流程图发生变更时,会向宿主推送 mpe:change 消息。变更类型包括:

type说明detail
node.add添加节点{ nodeId, taskName }
node.delete删除节点{ nodeCount, edgeCount }
node.update节点数据变更{ nodeCount, edgeCount }
edge.add添加边{ edgeId, source, target }
edge.delete删除边{ nodeCount, edgeCount }

警告

变更通知为尽力交付(best-effort),不做消息确认,宿主不应依赖其做精确的状态同步。需要精确状态时应使用 mpe:save 获取全量数据。

变更通知内置 300ms 防抖:在防抖窗口内发生多次变更时,仅发送最后一次的通知(detail 取最终值)。

保存请求

当用户在 MPE 内按下 Ctrl+S(或 Cmd+S)时,MPE 会发送 mpe:saveRequest 通知宿主:

typescript
{
  type: "mpe:saveRequest",
  payload: { hint: "user-triggered" }
}

宿主收到后可自行决定是否触发保存流程(发送 mpe:save → 接收 mpe:saveData)。

能力声明

宿主在 mpe:init 中通过 capabilities 字段声明期望的能力集合,MPE 会据此限制对应功能:

typescript
interface EmbedCapabilities {
  readOnly: boolean; // 只读模式,禁止编辑流程图
  allowCopy: boolean; // 允许复制/粘贴节点
  allowUndoRedo: boolean; // 允许撤销/重做
  allowAutoLayout: boolean; // 允许自动布局
  allowAI: boolean; // 允许 AI 辅助功能
  allowSearch: boolean; // 允许搜索面板
  allowCustomTemplate: boolean; // 允许自定义模板
}

各能力的作用范围:

能力生效方式
readOnly禁用节点拖拽、连接、双击创建、右键菜单;过滤掉所有 remove/add/position 类型的节点变更
allowCopy控制 Ctrl+C / Ctrl+V 是否可用
allowUndoRedo控制撤销/重做快捷键
allowAutoLayout控制自动布局按钮是否可用,不可用时点击会触发 mpe:errorcapability_denied
allowAI控制 AI 搜索按钮是否显示
allowSearch控制搜索面板是否显示
allowCustomTemplate控制自定义模板面板是否显示

默认权限集

若宿主未在 5 秒内发送 mpe:init,MPE 使用以下默认值自动完成握手:

能力默认值
readOnlyfalse
allowCopytrue
allowUndoRedotrue
allowAutoLayouttrue
allowAIfalse
allowSearchtrue
allowCustomTemplatetrue

默认集偏保守,仅开放核心编辑能力,关闭需要外部依赖(如 AI 服务)的功能。

UI 控制

宿主在 mpe:init 中通过 ui 字段控制 MPE 的界面元素:

typescript
interface EmbedUIConfig {
  hideHeader: boolean; // 隐藏顶部导航栏
  hideToolbar: boolean; // 隐藏左侧工具栏
  hiddenPanels: string[]; // 隐藏的面板 ID 列表
}

可隐藏的面板 ID

面板 ID说明
field字段面板
edge边面板
search搜索面板
file文件面板
config配置面板
ai-historyAI 历史面板
local-file本地文件面板
error错误面板
recognition-history识别历史面板
toolbar工具栏面板
logger日志面板
exploration流程探索面板

提示

顶部导航栏的本地服务连接按钮在嵌入模式下会始终显示为 "EmbedBridge",且点击不会触发断开连接。

完整的 EmbedInitConfig

typescript
interface EmbedInitConfig {
  capabilities: EmbedCapabilities;
  ui: EmbedUIConfig;
}

生命周期

宿主创建 iframe


MPE 加载,检测 embed=true


MPE 进入嵌入模式,等待 mpe:init

      ├──── 超时 5s ──► 使用默认权限集自动完成握手


收到 mpe:init,配置权限与 UI


回复 mpe:ready,握手完成


正常通信(loadPipeline / change / save ...)


宿主销毁 iframe(可选发送 mpe:destroy)


MPE 清理资源

安全考量

Origin 校验

MPE 在接收 postMessage 时按以下规则校验:

  1. 所有消息必须携带 protocol: "mpe-embed"
  2. 若 URL origin 参数以 http 开头,则要求 event.origin 严格匹配
  3. origin 参数为标识符形式(如 vscode-maa),仅记录日志,不阻断消息

版本协商

握手时 MPE 在 mpe:ready 中声明自身支持的协议版本(当前为 1.0.0)。版本策略遵循语义化版本:

  • 主版本号变更:不兼容的协议变更(消息格式、握手流程等)
  • 次版本号变更:向后兼容的功能新增(新增消息类型、新增 capability 字段)
  • 修订号变更:向后兼容的问题修正

宿主侧集成示例

以下是一个完整的宿主侧集成示例,展示了 iframe 创建、握手、消息收发、保存代理的完整流程:

html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>MPE 嵌入示例</title>
    <style>
      body {
        margin: 0;
        height: 100vh;
        display: flex;
      }
      #mpe-frame {
        flex: 1;
        border: none;
      }
    </style>
  </head>
  <body>
    <iframe
      id="mpe-frame"
      src="https://mpe.codax.site/stable/?embed=true&origin=my-app"
    ></iframe>

    <script>
      const iframe = document.getElementById("mpe-frame");
      const pendingRequests = new Map();
      let requestIdCounter = 0;

      // 发送消息,支持请求-响应模式
      function sendMessage(type, payload, needResponse = false) {
        const requestId = needResponse
          ? `req-${++requestIdCounter}`
          : undefined;
        const msg = {
          protocol: "mpe-embed",
          version: "1.0.0",
          type,
          ...(requestId ? { requestId } : {}),
          payload,
        };

        iframe.contentWindow.postMessage(msg, "*");

        if (needResponse) {
          return new Promise((resolve, reject) => {
            pendingRequests.set(requestId, { resolve, reject });
            setTimeout(() => {
              if (pendingRequests.has(requestId)) {
                pendingRequests.delete(requestId);
                reject(new Error(`Request ${type} timeout`));
              }
            }, 10000);
          });
        }
      }

      // 监听 MPE 消息
      window.addEventListener("message", (event) => {
        const data = event.data;
        if (!data || data.protocol !== "mpe-embed") return;

        // 处理请求-响应匹配
        if (data.requestId && pendingRequests.has(data.requestId)) {
          const { resolve } = pendingRequests.get(data.requestId);
          pendingRequests.delete(data.requestId);
          resolve(data.payload);
          return;
        }

        switch (data.type) {
          case "mpe:ready":
            console.log("MPE ready, version:", data.payload.version);
            console.log("Supported capabilities:", data.payload.supportedCaps);
            break;

          case "mpe:change":
            console.log(
              "Pipeline changed:",
              data.payload.type,
              data.payload.detail,
            );
            // 可在此处实现"未保存"标记
            break;

          case "mpe:saveRequest":
            console.log("MPE requests save:", data.payload.hint);
            // 触发保存流程
            triggerSave();
            break;

          case "mpe:saveData":
            console.log("Received pipeline data for:", data.payload.fileName);
            // 将 data.payload.data 写入文件系统
            break;

          case "mpe:error":
            console.error(
              "MPE error:",
              data.payload.code,
              data.payload.message,
            );
            break;
        }
      });

      // iframe 加载完成后发起握手
      iframe.addEventListener("load", () => {
        setTimeout(async () => {
          try {
            const result = await sendMessage(
              "mpe:init",
              {
                capabilities: {
                  readOnly: false,
                  allowCopy: true,
                  allowUndoRedo: true,
                  allowAutoLayout: true,
                  allowAI: false,
                  allowSearch: true,
                  allowCustomTemplate: false,
                },
                ui: {
                  hideHeader: false,
                  hideToolbar: false,
                  hiddenPanels: [
                    "config",
                    "local-file",
                    "logger",
                    "exploration",
                  ],
                },
              },
              true,
            );
            console.log("Handshake success:", result);
          } catch (err) {
            console.error("Handshake failed:", err);
          }
        }, 500);
      });

      // 加载 pipeline
      async function loadPipeline(fileName, pipelineData) {
        const result = await sendMessage(
          "mpe:loadPipeline",
          {
            fileName,
            data: pipelineData,
          },
          true,
        );
        console.log("Load result:", result);
      }

      // 保存 pipeline
      async function triggerSave() {
        const data = await sendMessage("mpe:save", {}, true);
        console.log("Save data received:", data);
        // 将 data.data 写入文件系统
      }

      // 选中节点
      function selectNode(nodeId) {
        sendMessage("mpe:selectNode", { nodeId });
      }

      // 聚焦节点
      function focusNode(nodeId) {
        sendMessage("mpe:focusNode", { nodeId });
      }
    </script>
  </body>
</html>

VSCode WebView 桥接

VSCode WebView 的 vscode.postMessage 与 iframe 内的 window.parent.postMessage 需要一层中继。宿主需在 WebView 的 HTML 中(iframe 外层)注入中继脚本:

html
<script>
  const vscode = acquireVsCodeApi();
  // iframe → 扩展进程
  window.addEventListener("message", (event) => {
    if (event.data?.protocol === "mpe-embed") {
      vscode.postMessage(event.data);
    }
  });
</script>
<iframe id="mpe-frame" src="...?embed=true&origin=vscode-maa"></iframe>

扩展进程中收到消息后,通过 iframe 的 contentWindow.postMessage 转发给 MPE:

typescript
// 扩展进程 → iframe
panel.webview.onDidReceiveMessage((msg) => {
  const iframe = document.getElementById("mpe-frame");
  iframe.contentWindow.postMessage(msg, "*");
});

MPE 侧代码完全不受影响,使用标准的 window.parent.postMessage 即可。

测试环境

项目源码的 /Iframe 目录下提供了一个完整的测试父级环境(index.html + test-host.js + test-host.css),可用于本地验证通信协议的所有消息类型。该测试环境独立于主项目技术栈,直接用浏览器打开即可使用。

测试环境提供了:

  • 能力开关(复选框形式配置 capabilities)
  • UI 配置(面板隐藏选项)
  • 所有消息类型的发送按钮
  • Pipeline JSON 输入区
  • 实时消息日志(按方向着色:接收为绿色,发送为蓝色,错误为红色)
  • 请求-响应超时提示

提示

测试环境不依赖任何构建工具,直接将 dist/ 构建产物与 Iframe/ 目录部署到同一静态服务器即可测试。